🐛 Coerce workgraph node attributes to JSON-safe values on save - #798
Conversation
Node attributes must be JSON-serializable, but workgraph data can carry non-serializable Python objects (e.g. enum defaults picked up from task function signatures), which made save_workgraph_data raise on store. Unwrap value-carrying objects recursively and fall back to str(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enum dict keys were left uncoerced, so json.dumps still raised "keys must be str, int, float, bool or None" on store. Add _ensure_json_safe_key to unwrap/stringify keys. Narrow the value fallback from any object with a non-callable .value to enum.Enum, so unrelated objects are stringified rather than silently unwrapped. Update the docstring to match (str fallback is lossy for set/frozenset). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cover plain-Enum unwrap, str-Enum/IntEnum no-op, enum dict-key coercion, set/frozenset stringification, narrowed non-enum .value fallback, and an end-to-end store negative control (raw plain-Enum dict raises at store; wrapped dict stores and round-trips). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Had a proper look at this, including where the data actually ends up. The approach is right and I'd keep the Gate the fallback on try:
clean_value(value)
except ValidationError:
return str(value)
return valueNothing is given up: everything MWE: a
|
- values clean_value accepts now pass through instead of being stringified (set, frozenset, numpy scalar, BaseType); storage coerces them itself on store - coerce any Mapping, not only dict (a tuple-keyed MappingProxyType passed the helper and raised in the database driver at store) - materialize one-shot iterators (a generator was stored as []) - exercise the save_workgraph_data wiring through WorkGraph.save() for graph-level and task-level error-handler kwargs Refs aiidateam#798 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- values clean_value accepts now pass through instead of being stringified (set, frozenset, numpy scalar, BaseType); storage coerces them itself on store - coerce any Mapping, not only dict (a tuple-keyed MappingProxyType passed the helper and raised in the database driver at store) - materialize one-shot iterators (a generator was stored as []) - exercise the save_workgraph_data wiring through WorkGraph.save() for graph-level and task-level error-handler kwargs Refs aiidateam#798 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8fc576a to
06120bf
Compare
- merge str-Enum/IntEnum no-op tests into one parametrized test - parametrize clean_value-coercible pass-through values and split the end-to-end store round-trip into its own test - parametrize iterator materialization; factories give each run a fresh, unconsumed iterator Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documented a helper no-op only; JSON-native enum members are covered by the storable-payload test. Remove the now-unused IntEnum fixture class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @GeigerJ2 — adopted both points. Gate: thanks, I wasn't aware of Wiring: added tests through Adversarial testing also turned up two further holes, both now fixed:
Updated the PR description accordingly. |
GeigerJ2
left a comment
There was a problem hiding this comment.
Great, thanks @elinscott!
Problem
Node attributes must survive aiida-core's
clean_value, but workgraph data can carry Python objects it rejects, sosave_workgraph_dataraised when the attributes were stored.What actually reaches the attribute path with such values is error-handler
kwargs, copied verbatim at two sites: a graph-level handler lands inworkgraph_error_handlers, a task-level one insideworkgraph_dataunder that task's spec. (An earlier version of this description claimed the trigger was anenum.Enumdefault in a task function signature — as measured in review, that raises earlier, ingeneral_serializerduringto_engine_inputs(), and never reaches the attribute path.)Change
_ensure_json_saferecursively coerces workgraph data before it is assigned to the process node:.valuestr()fallback is gated onclean_valuerather thanjson.dumps: only valuesclean_valuegenuinely rejects are stringified, while everything storage coerces itself (set/frozensetto list, numpy scalars to Python scalars,BaseTypeto its value) passes through untouchedMappinghas its keys coerced —clean_valuedoes not inspect keys, so e.g. a tuple-keyedMappingProxyTypeotherwise passes the helper and fails in the database driver atstore()clean_valueexhausts them as a side effect of validation, so an empty list would be stored otherwiseNotes
str()fallback is lossy by design (e.g. arbitrary objects become theirstr()).valueunwrap is limited toenum.Enum, so unrelated objects that merely expose a.valueattribute are not silently unwrappedNaN/infstill fail at store: thefloatshort-circuit precedes the gate; unchanged frommainbytespass through and store as a list of ints, matching what storage does without the helperTesting
Regression tests in
tests/test_utils.pycover:clean_value-coercible values (set,numpy.int64,orm.Int) including an end-to-end store round-tripdictMappingand iterator cases abovesave_workgraph_datawiring throughWorkGraph.save()for both error-handler sites; these fail if the_ensure_json_safecall sites are removed